Clean Code - Chapter 13 并发编程

2017-07-14 01:23

作者:给立乐*
出处:http://spencer-dev.com/2017/07/14/Clean Code - Chapter 13 并发编程
声明:本文采用以下协议进行授权: 自由转载-非商用-非衍生-保持署名|Creative Commons BY-NC-ND 3.0 ,转载请注明作者及出处。

并发编程

“对象是过程的抽象。线程是调度的抽象” - James O Coplien

编写整洁的并发程序很难 - 非常难。编写在单线程中执行的代码简单得多。编写表面上看起来不错、深入进去却支离破碎的多线程代码也简单。系统一旦遭受压力,这种代码就扛不住了。

本章将讨论并发编程的需求及其困难之处,并给出一些对付这些难点、编写整洁的并发代码的建议。最后,我们将讨论与测试并发代码有关的问题。

为什么要并发

并发是一种解耦策略。它们帮助我们把做什么(目的)和何时(时机)做分解开。在单线程应用中,目的与时机紧密结合,很多时候只要查看堆栈追踪即可断定应用程序的状态。调试这种系统的程序员可以设定断点或者断点序列,通过查看到达了那个断点来了解系统状态。

解耦目的与时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。系统因此会更易被理解,给出了许多切分关注面的有力手段。

例如,Web 应用的 Servlet 标准模式。这类系统运行于 Web 或 EJB 容器的保护伞之下, Web 或 EJB 为你部分地处理了并发问题。当有 Web 请求时,Servlet 就会异步执行。Servlet 程序员无需管理所有的请求。原则上,每次 Servlet 是在自己的小世界中执行,与其他 Servlet 的执行是分离的。

实际上,Web 容器提供的解耦手段离完美还差得远。Servlet 程序员得非常警惕、非常小心地保证并发程序不出错。同样 Servlet 模式的结构性好处还是很明显的。

但结构并非采用并发的唯一动机。有些系统对响应时间和吞吐量有要求,需要手工编写并发解决方案。例如,考虑一个单线程信息聚合程序,它从许多 Web 站点获取信息,再合并写入日志中。因为该系统是单线程的,它会逐个访问 Web 站点,再开始下一个之前等待当前站点访问完毕。每天的执行时间必须少于 24 个小时。然而,随着要访问的站点越来越多,采集所有数据花费的时间也越来越多,最终超过了 24 个小时的限制。单线程程序许多时间花在等待 Web 套接字 I/O 结束上面。通过采用同时访问多个站点的多线程算法,就能改进性能。

或者,考虑某个每次花费 1 秒钟处理一个用户请求的系统。该系统在用户量较少的时候响应及时,但随着用户数增加,系统响应时间也增加了。没人想排在 150 个人后面!通过并发处理多个用户请求,就能改进系统响应时间。

在活着,考虑某个解释大量数据集、但只在处理完全部数据后给出一个完整解决方案的系统。或许可以在独立的计算机上处理每个数据集,那样的话许多数据集就能并行地得到处理。

迷思与误解

看来有足够的理由采用并发方案。然而,如前文所述,并发编程很难。如果你不那么细心,就会搞出不看入目的东西来。看看以下常见的迷思和误解:

  • 并发总能改进性能

并发有时能改进性能,但只是在多个线程或处理器之间能分享大量等待时间的时候管用。事情没那么简单。

  • 编写并发程序无需修改设计

事实上,并发算法的设计有可能于单线程系统的设计极不相同。目的与时机的解耦往往对系统结构产生巨大影响。

  • 在采用 Web 或 EJB 容器的时候,理解并发问题并不重要。

实际上,你最好了解容器在做什么,了解如何对付并发更新、死锁等问题。

下面是一些有关编写并发软件的中肯说法:

  • 并发会在性能和编写额外代码上增加一些开销;
  • 正确的并发是复杂的,即便对于简单的问题也是如此;
  • 并发缺陷并非总能重现,所以常被看作偶发事件而忽略,未被当作真的缺陷看待;

挑战

并发编程为何如此之难?来看看下面这个小型类:

1
2
3
4
5
6
7
8
9
public class X {
private int lastIdUsed;
public int getNextId() {
return ++lastIdUsed;
}
}

比如,创建 X 的一个实体,将 lastIdUsed 设置为 42,在两个线程中共享这个实体。假设这两个线程都调用 getNextId() 方法,结果可能有三种输出:

  • 线程一得到值 43,线程二得到值 44,lastIdUsed 为 44;
  • 线程一得到值 44,线程二得到值 43,lastIdUsed 为 44;
  • 线程一得到值 43,线程二得到值 43,lastIdUsed 为 43;

第三种结果令人惊异,当两个线程互相影响时就会出现这种情况。这是因为线程在执行那行 java 代码时有许多可能路径可行,有些路径会产生错误的结果。有多少种不同路径呢?要真正回答这个问题,需要理解 Just-In-Time 编译器如何对待生成的字节码,还要理解 Java 内存模型认为什么东西具有原子性。

简答一下,就生成字节码而言,对于在 getNextId() 方法中执行的那两个线程,有 12870 种不同的可能执行路径。如果 lastIdUsed 的类型从 int 变为 long,则可能路的数量将增至 2704156 种。当然,多数路径都能得到正确结果。问题是其中一些不能得到正确结果。

并发防御原则

下面给出一系列防御并发代码问题的原则和技巧。

单一权责原则

单一权责原则(SRP)认为,方法/类/组建应当只有一个修改的理由。并发设计自身足够复杂到成为修改的理由,所以也该从其他代码中分离出来。不幸的是,并发实现细节常常直接嵌入到其他生产代码中。下面是要考虑的一些问题:

  • 并发相关代码有自己的开发、修改和调优生命周期;
  • 并发相关代码有自己要对付的挑战,和非并发相关代码不同,而且往往更为困难;

即便没有周边应用程序增加的负担,写得不好的并发代码可能出错的方式数量也已经足具挑战性。

建议:分离并发相关代码与其他代码。

推论:限制数据作用域

如我们所见,两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。解决方案之一是采用 synchronized 关键字在代码中保护一块使用共享对象的临界区(critical section)。限制临界区的数量很重要。更新共享数据的地方越多,就越有可能:

  • 你会忘记保护一个或多个临界区 - 破坏了修改共享数据的代码;
  • 得多花力气保证一切都收到有效保护(破坏了 DRY 原则);

很难找到错误源,也很难判断错误源。

建议:谨记数据封装;严格限制对可能被共享的数据的访问。

推论:使用数据复本

避免共享数据的好方法之一就是一开始就避免共享数据。在某些情形下,有可能复制对象并以只读的方式对待。在另外的情况下,有可能复制对象,从多个线程收集所有复本的结果,并在单个线程中合并这些结果。

如果有避免共享数据的简易手段,结果代码就会大大减少导致错误的可能。你可能会关心创建额外对象的成本。值得试验一下看看那是否真是个问题。然而,假使使用对象复本能避免代码同步执行,则因避免了锁定而省下的价值有可能补偿得上额外的创建成本和垃圾收集开销。

推论:线程应尽可能地独立

让每个线程在自己的世界中存在,不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求,存储为本地变量。这样一来,每个线程都像是世界中的唯一线程,没有同步的需要。

例如,HttpServlet 的子类接收所有以参数形式传递给 doGet 和 doPost 方法的信息。每个 Servlet 都像拥有独立虚拟机一般运行。只要 Servlet 种的代码只使用本地变量,Servlet 就不会导致同步问题。当然,多数使用 Servlet 的应用程序最终还是会用到类似数据库连接之类的共享资源。

建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。

了解 Java 库

相对于之前的版本,Java 5 提供了许多并发开发方面的改进。在用 Java 5 编写线程代码时,要注意以下几点:

  • 使用类库提供的线程安全群集;
  • 使用 executor 框架(executor framework);
  • 尽可能使用非锁定解决方案;

有几个类并不是线程安全的。

线程安全集群

当 Java 还年轻时,Doug Lea 编写了 Concurrent Programming in Java (中译版《Java 并发编程》)教程,同时开发了几个线程安全群集,这些代码后来成为 JDK 种 java.util.concurrent 包中的一部分。该代码中的群集对于多线程解决方案是安全的,执行良好。实际上,在几乎所有情况下,ConcurrentHashMap 实现都比 HashMap 表现得好。它还支持同步并发读写,也拥有支持非线程安全的合成操作的方法。

还有几个支持高级并发设计的类。以下是其中的一小部分。

  • ReentrantLock ,可在一个方法中获取、在另一个方法中释放的锁;
  • Semaphore,经典的“信号”的一种实现,有计数器的锁;
  • CountDownLatch,在释放所有等待的线程之前,等待指定数量时间发生的锁。这样,所有线程都平等地几乎同时启动;

建议:检读可用的类。对于 Java,掌握 java.util.concurrent、java.util.concurrent.atomic 和 java.util.concurrent.locks。

了解执行模型

有几种在并发应用中切分行为的途径。要讨论这些途径,我们需要理解一些基础定义。

  • 限定资源:并发环境中有着固定尺寸或数量的资源。例如数据库连接和固定尺寸读/写缓存等;
  • 互斥:每一时刻仅有一个线程能访问共享数据或共享资源;
  • 线程饥饿:一个或一组线程在很长时间内或永久被禁止。例如,总是让执行的快的线程线运行,假如执行的快的线程没完没了。则执行时间长的线程就会“挨饿”;
  • 死锁:两个或多个线程互相等待之行结束。每个线程都拥有其他线程需要的资源,得不到其他线程拥有的资源,就无法终止。
  • 活锁:执行次序一致的线程,每个都想要起步,但发现其他线程已经“在路上”。由于竞步的原因,线程会持续尝试起步。但在很长时间内却无法如愿,甚至永远无法启动。

有了这些定义,我们就能讨论在并发编程中用到的几种执行模型了。

生产者 - 消费者模型

一个或多个生产者线程来创建某些工作,并置于缓存或队列中。一个或多个消费者线程从队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源。

读者 - 作者模型

当存在一个主要为读者线程提供信息源,但只偶尔被作者线程更新的共享资源,吞吐量就会是个问题。增加吞吐量,会导致线程饥饿和过时信息的累积。更新会影响吞吐量。协调读者线程,不去读作者线程正在更新的信息(反之亦然),这是一种辛苦的平衡工作。作者线程倾向于长期锁定许多读者线程,从而导致吞吐量问题。

挑战之处在于平衡读者线程和作者线程的需求,实现正确操作,提供合理的吞吐量,避免线程饥饿。

宴席哲学家

想象一下,一群哲学家环坐在圆桌旁。每个哲学家的左手边放了一把叉子。桌面中央摆着一大碗意大利面。哲学家们思索良久,直到肚子饿了。每个人都要拿起叉子吃饭。但除非手上有两把叉子,否则就没法进食。如果左边或右边的哲学家已经取用一把叉子,中间这位就得等到别人吃完、放回叉子。每位哲学家吃完后,就将两把叉子放回桌面,直到肚子再饿。

用线程代替哲学家,用资源代替叉子,就变成了许多企业级应用中进程竞争资源的情形。如果没有用心设计,这种竞争式系统就会遭遇死锁、活锁、吞吐量和效率降低等问题。

你可能遇到的并发问题,大多数都是这三个问题的变种。请研究并使用这些算法,这样,遇到并发问题时你就能有解决问题的准备了。

建议:学习这些基础算法,理解其解决方案。

警惕同步方法之间的依赖

同步方法之间的依赖会导致并发代码中的狡猾缺陷。Java 语言有 synchronized 概念,可以用来保护单个方法。然而,如果同一共享类中有多个同步方法,系统就可能写的不太正确了。

建议:避免使用一个共享对象的多个方法。

有时必须使用一个共享对象的公共方法。这种情况发生时,有 3 种写对代码的手段:

  • 基于客户端的锁:客户端代码在调用第一个方法前锁定服务端,确保锁的范围覆盖了调用最后一个方法的代码;
  • 基于服务端的锁定:在服务端内创建锁定服务端的方法,调用所有方法,然后解锁。让客户端代码调用这个新方法;
  • 适配服务端:创建执行锁定的中间层。这是一种基于服务端锁定的例子,但不修改原始服务端代码;

保持同步区域微小

关键字 synchronized 制造了锁。同一个锁维护的所有代码区域在任一时刻保证只有一个线程执行。锁是昂贵的,因为它们带来了延迟和额外开销。所以我们不愿将代码扔给 synchronized 语句了事。另一方面,临界区应该被保护起来。所以,应该尽可能少地设计临界区。

有些天真的程序员想通过扩大临界区面积达到这个目的。然而,将同步延展到最小临界区范围外,会增加资源争用、降低执行效率。

建议:尽可能减小同步区域。

很难编写正确的关闭代码

编写永远运行的系统,与编写运行一段时间后平静地关闭的系统是两码事。

平静关闭很难做到。常见问题与死锁有关,线程一直等待永远不会到来的信号。

例如,想象一个系统中有个父线程分裂出数个子线程,父线程等待所有子线程结束,然后释放资源并关闭。如果其中一个子线程发生死锁会怎样?父线程将一直等待下去,而系统就永远不能关闭。

或者,考虑一个被指示关闭的类似系统。父线程告知全体子线程放弃任务并结束。如果其中两个子线程正以生产者/消费者模型操作会怎样呢?假设生产者线程从父线程处接收到信号,并迅速关闭。消费者线程可能还在等待生产者线程发来信号,于是就被锁定在无法接收到关闭信号的状态中。它会死等生产者线程,永不结束,从而导致父线程也无法结束。

这类情形并非那么不常见。如果你要编写涉及平静关闭的并发代码,请多预留一些时间搞对关闭过程。

建议:尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既有算法,因为这可能会比你想象中难的多。

测试线程代码

证明代码的正确性不切实际。测试并不能确保正确性。然而,好的测试却能尽量降低风险。这对于所有单线程解决方案都是对的。当有两个或多个线程使用同一代码段和共享数据,事情就变得非常复杂了。

建议:编写有潜力曝露问题的测试,在不同的编程配置、系统配置和负载条件下频繁运行。如果测试失败,跟踪错误。别因为测试通过了,就忽略以后的运行失败。

有一大堆问题要考虑。下面是一些精炼的建议:

  • 将伪失败看作可能的线程问题;
  • 先使非线程代码可工作;
  • 编写可插拔的线程代码;
  • 编写可调整的线程代码;
  • 运行多于处理器数量的线程;
  • 在不同平台上运行;
  • 调整代码并强迫错误发生;

将伪失败看作可能的线程问题

线程代码导致“不可能失败的失败”。多数开发者缺乏有关线程如何与其他代码(可能由其他作者编写)互动的直觉。线程代码中的缺陷可能在一千或一百万次执行中才会显现一次。重复执行想要复现问题令人沮丧。所以开发者常常会将失败归结于宇宙射线、硬件错误或其他“偶发事件”。最好假设这种偶发事件根本不存在。“偶发事件”被忽略的越久,代码就越有可能搭建与不完善的基础之上。

建议:不要将系统错误归结于偶发事件。

编写可插拔的线程代码

编写可在数个配置环境下运行的线程代码:

  • 单线程与多线程在执行时不同的情况;
  • 线程代码与实物或测试替身互动;
  • 用运行加速、缓慢和有变动的测试替身执行;

将测试配置为能运行一定数量的迭代。

建议:编写可插拔的线程代码,这样就能在不同的配置环境下运行。

编写可调整的线程代码

要获得良好的线程平衡,常常需要试错。一开始,在不同的配置环境下监测系统性能。要允许线程数量可调整。在系统运行时允许线程发生变动。允许线程依据吞吐量和系统使用率自我调整。

运行多于处理器数量的线程

系统在切换任务时会发生一些事。为了促使任务交换的发生,运行多于处理器或处理器核心数量的线程。任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。

在不同平台上运行

不同操作系统有着不同的线程策略,不同的线程策略影响了代码的执行。在不同环境中,多线程代码的行为也不一样。应该在所有可能部署的环境中运行测试。

建议:尽早并经常地在所有目标平台上运行线程代码。

装置试错代码

并发代码中藏有缺陷,这并不罕见。简单的测试往往无法曝露这些缺陷。实际上,缺陷经常隐藏于一般处理过程中。可能好几个小时、好几天甚至好几个星期才会跳出来一次!

线程中的缺陷之所以如此不频繁、偶发、难以重现,是因为在几千个穿过脆弱区域的可能路径当中,只有少数几个路径会真的导致失败。经过会导致失败的路径的可能性惊人地低。所以,侦测与调试也非常之难。

怎么才能增加捕捉住如此罕见之物的机会?可以装置代码,增加对 Object.wait()、Object.sleep()、Object.yield() 和 Object.priority() 等方法的调用,改变代码执行顺序。

这些方法都会影响执行顺序,从而增加了侦测到缺陷的可能性。有问题的代码,最好尽早、尽可能多地通不过测试。

有两种装置代码的方法:

  • 硬编码;
  • 自动化;

硬编码

你可以手工向代码中插入 wait()、sleep()、yield() 和 priority() 的调用。在测试某段棘手的代码时,正当如此操作。

1
2
3
4
5
6
7
8
9
public synchronized String nextUrlOrNull() {
if (hasNext()) {
String url = urlGenerateor.next();
Thread.yield(); // inserted for testing.
updateHasNext();
return url;
}
return null;
}

插入对 yield() 的调用,将改变代码的执行路径,由此而可能导致代码在以前未失败过的地方失败。如果代码的确出错,那并非是因为你插入了 yield() 方法调用。代码出错了,这便是失败的原因。

这种手法有许多毛病:

  • 你得手工找到合适的地方来插入方法调用;
  • 你怎么知道在哪里插入调用、插入什么调用?
  • 不必要地在产品环境中留下这类代码,将拖慢代码执行速度;

这种无的放矢的手段。你可能找不到缺陷。实际上,这不在你把握之中。

我们所需要的,是一种在测试中但不在生产中实现的手段。我们还需要为多次运行轻易地调整配置,而增加总的发现错误机会。

无疑,如果系统分解为对线程及控制线程的类一无所知的 POJO,就能更容易地找到装置代码的位置。而且,还能创建许多个以不同方式调用 sleep、yield 等方法的 POJO 测试。

自动化

可以使用 Aspect-Oriented Framework、CGLIB 或 ASM 之类工具通过编程来装置代码。

例如,可以使用有单个方法的类:

1
2
3
4
5
public class ThreadJigglePoint {
public static void jiggle() {
}
}

可以在代码的不同位置调用这个方法:

1
2
3
4
5
6
7
8
9
10
11
public synchronized String nextUrlOrNull() {
if (hasNext()) {
ThreadJiglePoint.jiggle();
String url = urlGenerateor.next();
ThreadJiglePoint.jiggle();
updateHasNext();
ThreadJiglePoint.jiggle();
return url;
}
return null;
}

如此,你就得到了一个随机选择无所作为、睡眠或让步的方面。

或者,想象 ThreadJigglePoint 类有两种实现。第一种实现 jiggle 什么都不做,在生产环境中使用。第二种实现生成一个随机数,在睡眠、让步或径直执行间做选择。如果上千次地做这种随机测试,大概就能找到一些缺陷的根源。假如测试都通过了,至少你可以说自己已谨慎对待。这种方法看似有点过于简单了,但确是代替复杂工具的一种可选方案。

有一种叫做 ConTest 的工具类,由 IBM 开发,能做类似的事情,但做法却稍微复杂些。

要点是让代码“异动”,从而使线程以不同次序执行。编写良好的测试与“异动”相组合,能有效地增加发现错误的机会。

建议:使用异动策略搜出错误。

小结

并发代码很难写正确。加入多线程和共享数据后,简单的代码也会变成噩梦。要编写并发代码,就得严格的编写整洁的代码,否则将面临微细和不频繁发生的失败。

第一要诀是遵循单一权责原则。将系统切分为分离了线程相关代码和线程无关代码的 POJO。确保在测试线程相关代码时只是在测试,没有做其他事情。线程相关代码应该保持短小和目的集中。

了解并发问题的可能原因:对共享数据的多线程操作,或使用了公共资源池。类似平静关闭或停止循环之类边界情况尤其棘手。

学习类库,了解基本算法。理解类库提供的与基础算法类似的解决问题的特性。

学习如何找到必须锁定代码区域并锁定之。不要锁定不必锁定的代码。避免从锁定区域中调用其他锁定区域。这需要深刻理解某物是否已共享。尽可能减少共享对象和共享范围。修改对象的设计,向客户代码提供共享数据,而不是迫使客户代码管理共享状态。

问题会跳出来。那种在早期没跳出来的问题往往是偶发的。这种所谓偶发问题,通常仅在高负载下出现或偶然出现。所以,你要能在不同平台上、以不同配置持续重复运行线程代码。跟随 TDD 三要则而来的可测试性意味着某种程度的可插拔性,从而提供了在大量不同配置下运行代码的必要支持。

如果花点时间装置代码,就能极大地提升发现错误代码的机会。可以手工做,也可以使用某种自动化技术。尽早这么做。在将线程代码投入生产环境前,就要尽可能多地运行它。

只要采用了整洁的做法,做对的可能性就有翻天覆地的提高。


Comments: